iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 6
0
Modern Web

Web x Sound - 用 Web 玩轉聲音系列 第 6

Day06 - 使用 HTML 播放音檔 - 自製播放器 (3)

  • 分享至 

  • xImage
  •  

今天是自製播放器的最後一帕,來講講實際與 UI 互動的串接,以及解說 Audio Player 自製模組的運作原理。

custom-audio-player
Demo on CodePen

概覽

const mediaData = [{ ... }, { ... }, { ... }, { ... }, { ... }];

// 格式化秒數
const formatTime = sec => { ... }

// 播放器功能
class AudioPlayer { ... }

// UI 互動
$(document).ready(() => {
    // ... 各種 DOM selector
    
    // ... 各種 render functions
    
    const myAudio = new AudioPlayer(mediaData);
    
    // ... 各種事件監聽
    
    renderPlaylist(mediaData);
})

播放、暫停功能

這個功能直覺是滿簡單的,但是實作上有踩到一些觀念上的盲點,值得提出來講講。

切換播放狀態

// 建立自訂事件
this.event = {
    isPlaying: new Event("playstatuschange"),
}

// 調整播放狀態
setPlayStatus(val) {
    if (typeof val !== 'boolean') return;
    this.isPlaying = val;
    this.audioPlayer.dispatchEvent(this.event.isPlaying);
}

// 播放 / 暫停
togglePlay(nextIsPlaying = !this.isPlaying) {
    if (nextIsPlaying) {
        // 播放
        this.audioPlayer.play()
            .then(() => this.setPlayStatus(true))
            .catch(e => console.error('播放發生錯誤', e))	
    } else {
        // 暫停
        this.audioPlayer.pause()
        this.setPlayStatus(false);
    }
}

首先,Audio Player 使用 this.isPlaying 儲存播放狀態,這裡有個重點:

this.isPlaying 代表「是否繼續播放」,與播放器本身的停止、播放不是同一件事

像是點暫停會觸發 pause、播放中延遲重 loading 也會觸發 pause、播放一首歌結束也會觸發 pause。要實作換歌時自動播放、整份歌單結束時停止 ... 等常見的行為,就需要區分兩者。

顯示播放、暫停按鈕

在狀態變更時,會觸發自訂事件 playstatuschange ,因此透過取得狀態與監聽此事件,就能決定 UI 的顯示。

// Material UI 的 Icon Font 使用文字而非 class name 切換圖示
const myAudio = new AudioPlayer(mediaData);
myAudio.on('playstatuschange', () => playBtn.html(myAudio.getIsPlaying() ? 'pause' : 'play_arrow'))

狀態同步

// 錯誤狀態停止播放
this.audioPlayer.addEventListener('playing', () => this.setPlayStatus(true))
this.audioPlayer.addEventListener('waiting', () => this.setPlayStatus(false))
this.audioPlayer.addEventListener('error', () => this.setPlayStatus(false))
this.audioPlayer.addEventListener('stalled', () => this.setPlayStatus(false))

針對一些非預期的事件監聽讓 this.isPlaying 狀態與之同步。

name type description
playing event listener 資料足以繼續播放時觸發
waiting event listener 資料不足以繼續播放時觸發
stalled event listener 歌曲資料不能用時觸發
error event listener 任何錯誤時觸發

處理自動播放

this.event = {
    current: new Event("currentmusicchange"),
}

// 換歌時判斷是否繼續播放
this.audioPlayer.addEventListener('currentmusicchange', () => {
    this.isPlaying ? this.audioPlayer.play() : this.audioPlayer.pause()
})

// 自動播下一首
this.audioPlayer.addEventListener('ended', () => {
    const nextIdx = this.getNextMusicIdx();
    const stopWhenReachPlaylistEnd = (this.playMode === 'step' && nextIdx === 0)
    this.setCurrentMusic(nextIdx);
    this.setPlayStatus(!stopWhenReachPlaylistEnd);
})

我們監聽 ended 事件用以處理一首歌播放完畢後的動作,並在自訂事件 currentmusicchange 換歌時才決定是否要讓播放器開始播。

有發現到上述的事件中,完全沒有監聽 playpause 嗎?
其實我們不需要知道播放器是否正在播放,只要在相關的事件監聽有正確的處理即可,故只需維護 this.isPlaying 這個狀態。

歌曲的切換功能

我們使用 this.currentIdx 紀錄現在要播哪一首歌,並設計了兩個方法切換、讀取要播的歌,可以做到直接指定、或是根據規則自動播下一首歌曲:

// 指定並讀取當下要播放的音樂
// identifier = prev, next, 0, <integer>
setCurrentMusic(identifier = this.currentIdx) {
    if (Number.isInteger(identifier)) {
        if (identifier < 0 || identifier >= this.playList.length) return;
        this.currentIdx = identifier;
    } else if (typeof identifier === 'string') {
        const newIdx = this.getNextMusicIdx(identifier);
        if (!newIdx) return;
        this.currentIdx = newIdx;
    } else {
        console.error('不合法的 identifier in setCurrentMusic');
        return;
    }

    this.audioPlayer.setAttribute('src', this.getMediaInfo().fileUrl)
    this.audioPlayer.load()
}

// 取得下一首要播放的歌曲
getNextMusicIdx(operation = this.playMode) {
    let nextIdx = 0;
    switch (operation) {
        case 'step':
            // 到播放清單底,結束播放
            if ((this.currentIdx + 1) >= this.playList.length) this.audioPlayer.pause();
            nextIdx = (this.currentIdx + 1) >= this.playList.length ? 0 : this.currentIdx + 1;
            break;

        case 'next':
        case 'repeat-all':
            nextIdx = (this.currentIdx + 1) >= this.playList.length ? 0 : this.currentIdx + 1;
            break;

        case 'prev': 
            nextIdx = (this.currentIdx - 1) < 0 ? (this.playList.length - 1) : (this.currentIdx - 1);
            break;

        case 'shuffle':
            nextIdx = Math.floor(Math.random()*this.playList.length - 1);
            break;

        case 'repeat-one':
            nextIdx = this.currentIdx;
            break;

        default:
            console.log('不合法的操作', operation);
            return;
    }

    return nextIdx;
}

顯示當前播放的歌曲資訊

const renderCurrent = (info, currentTime, duration) => {
    $('#current-thumb').attr('src', info.thumb)
    $('#current-author').attr('href', info.authorUrl || '#')
    $('#current-author').html(info.author || 'Author')
    $('#current-name').html(info.fileName || 'Song Name')
    $('#passtime').html(formatTime(currentTime))
    $('#duration').html(formatTime(duration))
}

myAudio.on('currentmusicchange', () => {
    const currentTime = myAudio.getCurrentTime()
    const duration = myAudio.getDuration()
    renderCurrent(myAudio.getMediaInfo(), myAudio.getCurrentTime(), myAudio.getDuration());
    passtime.html(formatTime(currentTime))
})

監聽 currentmusicchange 自訂事件來顯示歌曲資訊。它會發生在 durationchange 之後,因此確保拿到的歌曲總時間不會是 NaN 。

上一首歌、下一首歌

仔細分析一下切換歌曲相關的功能,上一首歌、下一首歌這兩個性質和隨機播放、循環播放不同,除了會改變 this.currentIdx ,還需要立即讀取歌曲。而隨機播放、循環播放不會立即生效,等到一首歌播完後才有作用。

因此這兩個按鈕是這樣做的:

const prevBtn = $('#prev')
const nextBtn = $('#next')

// 上一首歌 or 下一首歌
prevBtn.click(() => myAudio.setCurrentMusic(myAudio.getNextMusicIdx('prev')))
nextBtn.click(() => myAudio.setCurrentMusic(myAudio.getNextMusicIdx('next')))

歌單顯示、操作

點擊歌單裡的歌曲,可以切換到那首歌播放,實作方法與上下一首歌相同:

// 歌曲資訊元件
const MusicInfo = (info, idx) => {
    return `
        <div class="info">
            <img class="info__thumb" src="${info.thumb}"/>
            <div class="info__wrapper">
                <p class="info__author" title="${info.author || 'Author'}">${info.author || 'Author'}</p>
                <span class="info__name">${info.fileName || 'Song Name'}</span>
            </div>
        </div>
    `;
}

// 播放清單
const renderPlaylist = playlist => {
    $('#playlist').html(mediaData.map((musicInfo, idx) => `<div id="queue-item-${idx}" class="queue__item">${MusicInfo(musicInfo)}</div>`))

    $('#playlist .queue__item').click(function() {
        const idx = parseInt($(this).attr('id').replace("queue-item-", ''));
        myAudio.setCurrentMusic(idx)
    });
}

renderPlaylist(mediaData);

播放模式:一般、隨機、循環 (單首、全部)

針對「自動決定下一首歌」的部分,我們設計了播放模式 this.playMode 處理這件事。

// 建立自訂事件
this.event = {
    playMode: new Event("playmodechange"),
}

// 調整播放模式
setPlayMode(mode) {
    const validMode = ['step', 'shuffle', 'repeat-one', 'repeat-all'];
    if (validMode.indexOf(mode) !== -1) {
        this.playMode = mode;
        this.audioPlayer.dispatchEvent(this.event.playMode);
    }
}

// 取得當前播放模式
getPlayMode() {
    return this.playMode;
}

一共分成四種:一般 (step)、隨機 (shuffle)、單曲循環 (repeat-one)、歌單循環 (repeat-all),這些只是簡單的「開關」,實際上處理邏輯是寫在 getNextMusicIdx 中,當遇到 ended 事件時,就會呼叫 getNextMusicIdx 自動判斷要播哪首歌。

一樣透過監聽自訂事件 playmodechange 處理 UI 顯示

myAudio.on('playmodechange', () => {
    switch(myAudio.playMode) {
        case 'step': {
            shuffleBtn.removeClass('select')
            repeatBtn.removeClass('select')
            repeatBtn.html('repeat')
            break;
        }
        case 'shuffle': {
            shuffleBtn.addClass('select')
            repeatBtn.removeClass('select')
            repeatBtn.html('repeat')
            break;
        }
        case 'repeat-one': {
            shuffleBtn.removeClass('select')
            repeatBtn.addClass('select')
            repeatBtn.html('repeat_one')
            break;
        }
        case 'repeat-all': {
            shuffleBtn.removeClass('select')
            repeatBtn.addClass('select')
            repeatBtn.html('repeat')
            break;
        }
        default:
            return;
    }
})

音量、進度條操作

這兩個做法其實很類似,就一起討論吧!

拖曳條

拖曳條實際上分成三層:圓點 (handle)、可拖曳區域條 (bg)、可伸縮的區域條 (bar)。

<!-- 進度條 -->
<div class="player__timeline player__item">
  <div class="timeline"><span class="timeline__passtime" id="passtime">00:00:00</span>
    <div class="timeline__progress-wrapper">
      <div class="timeline__progress-bg" id="timeline-bg"></div>
      <div class="timeline__progress-bar" id="timeline-bar"></div>
      <div class="timeline__progress-handle" id="timeline-handle"></div>
    </div><span class="timeline__duration" id="duration">00:10:59</span>
  </div>
</div>

<!-- 音量條 -->
<div class="volume">
  <div class="volume__wrapper hidden" id="volume-wrapper">
    <div class="volume__progress-bg" id="volume_bg"></div>
    <div class="volume__progress-bar" id="volume_bar"></div>
    <div class="volume__progress-handle" id="volume_handle"></div>
  </div>
</div>

這邊使用 jQuery UI 的 draggable,將 handle 變成可拖曳,限制可拖方向、可拖區域, bar 會隨著 drag 事件伸縮:

const timelineBarTotalLength = 250; // px
const volumeBarTotalLength = 100; // px

// 拖動時間軸
timelineHandle.draggable({ 
    axis: "x",
    containment: timelineBg,
    start: (event, ui) => myAudio.togglePlay(false),
    drag: (event, ui) => {
        const nextSec = Math.floor(ui.position.left * (myAudio.getDuration() / timelineBarTotalLength));
        passtime.html(formatTime(nextSec));
        timelineBar.width(`${ui.position.left}px`)
    },
    stop: (event, ui) => {
        const nextSec = Math.floor(ui.position.left * (myAudio.getDuration() / timelineBarTotalLength));
        myAudio.setCurrentTime(nextSec);
        myAudio.togglePlay(true);
    }
})

// 調整音量
volumeHandle.draggable({ 
    axis: "y",
    containment: volumeBg,
    drag: (event, ui) => {
        const vol = volumeBarTotalLength - ui.position.top;
        volumeBar.height(`${vol}px`);
        myAudio.setVolume(vol);
    }
})

拖放時會觸發的事件

// 音量調整 (0 - 100)
setVolume(vol) {
    if (typeof vol !== 'number') return;
    this.audioPlayer.volume = vol / 100;
}

// 進度條調整
setCurrentTime(nextSec) {
    const currentMusic = this.getMediaInfo();
    if (!currentMusic) return;
    if (nextSec > currentMusic.duration) {
        this.audioPlayer.currentTime = 0;
    } else if (nextSec < 0) {
        this.audioPlayer.currentTime = 0;
    } else {
        this.audioPlayer.currentTime = nextSec;
    }
}

顯示最新狀態

此外,我們監聽 timeupdatevolumechange 事件,一有變化就顯示新的 UI 。

// 取得當前音量
getVolume() {
    return this.audioPlayer.volume;
}

// 取得當前秒數
getCurrentTime() {
    return this.audioPlayer.currentTime;
}

// 取得當前歌曲總長度
getDuration() {
    return this.audioPlayer.duration;
}

// 進度條
myAudio.on('timeupdate', () => {
    const currentTime = myAudio.getCurrentTime()
    const duration = myAudio.getDuration()
    passtime.html(formatTime(currentTime))
    timelineBar.width(`${(currentTime / duration) * timelineBarTotalLength}px`)
    timelineHandle.css('left', `${(currentTime / duration) * timelineBarTotalLength}px`);
})

// 音量條
myAudio.on('volumechange', () => volumeBar.height(`${myAudio.getVolume() * volumeBarTotalLength}px`))

小結

我們使用 HTMLAudioElement 、 Media Event 、 Custom Event,實作出功能齊全的小型播放器了! (灑花)透過這些就能玩出很多花樣。

全靠監聽 Event 做事情可以省去很多同步問題,但在設計模組介面時需要特別小心,如果沒有釐清好什麼 Event 該負責什麼任務、每項功能觸發的 event flow 有哪些,反而 debug 上會更加困難。

使用 HTML 播放音檔的部分就先告個段落了,接下來會開始談概念性的東西,像是聲音檔案格式與編碼、數位音樂的基本,為之後的 Web Audio API 先打點底吧!

Reference


上一篇
Day05 - 使用 HTML 播放音檔 - 自製播放器 (2)
下一篇
Day07 - 音訊格式介紹
系列文
Web x Sound - 用 Web 玩轉聲音13
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言